其他
异常≠错误,正如Bug≠事故,详解业务开发中的异常处理
👉导读
软件开发中遇到异常才是正常,很少有人能写出完美的程序跑在任何机器上都不会报错。但极为正常的软件异常,却经常出自不同的原因,导致不同的结果。怎么样科学地认识异常、处理异常,是很多研发同学需要解决的问题。本文作者根据自己多年的工作经验,撰写了《异常思辨录》系列专栏,希望能体系化地帮助到大家。本文为系列第三篇,本篇文章将主要聚焦业务开发对异常处理的需求点和一些优秀的异常处理案例,欢迎阅读。👉目录
1 业务开发对异常处理的需求点2 优秀的异常处理方案 2.1 异常的建模 2.2 异常的兜底2.3 其他人性化的思考
01
1.1 需求点 1:内存安全性
static std::atomic<int> thread_counter;
void foo() {
thread_local int tid = ++thread_counter;
try {
throw MyException("Data from thread", tid);
} catch (const MyException &ex) {
assert(ex.tid == tid);
}
}
foo
,C++ 11 已经能够保证 throw
的异常和捕获住的异常是同一个对象,不会出现线程读写冲突,因为每个 std::current_exception()
都是线程变量而非全局变量。C++11 还有更高级的用法,使用 std::current_exception()
和 std::rethrow_exception()
,可以将一个线程的异常获取保存下来并在另外的线程抛出。return_value
和 return_void 函数包装在了 try/catch
中,所以你不需要再这么做。__cxa_allocate_exception
分配异常对象的内存,在 catch 之后使用 __cxa_free_exception
来释放内存,而通过分析也可知道异常对象的内存是在栈上保存的,不存在异常抛出时协程切换导致异常对象被其他协程修改。1.2 需求点 2:关注点分离
断言错误发生的时机 如果是原发性逻辑错误,需要对逻辑进行判断 如果是转发上层错误,需要对返回值进行判断 设置需要返回的错误码 打印日志,日志中需要包含可能的上下文信息 做 Oss 上报,用于分钟级曲线监控,以便对该异常做全局监控或报警 (可选)做 mmdata 上报,用于同时上报场景信息(如发生的商户号、用户 Uin等),以便对该异常做分场景的上报,为 KA 商户等场景做特殊告警等 (可选)接入层模块中还会对最终用户的文案进行错误码转义或组装
在抛出异常时记录调用帧的信息,这样就在回溯时可以拿到完整的调用链路; 业务只需要关注自己能够处理的异常,对于无法处理的异常,交给上层来处理; 在抛出异常前可以对异常的错误码、监控、上报进行统一的处理; 错误信息或日志完全可以在捕获异常时进行处理,如果不能捕获,框架应该统一处理。
1.3 需求点 3:框架兜底行为
开发环境:框架的行为应该是尽可能暴露问题,为开发者提供完整的错误上下文,使得开发者可以快速定位问题修复代码。如 Windows 下的 Debug 编译的 exe 文件可以显示友好的异常上下文,配合 pdb 文件可以直观的显示源代码中调用帧和异常发生的位置。 生产环境:框架应该尽力兜底错误,恢复职能。保证自身框架的稳定性,不应影响其他并行的业务接口。
1.4 需求点 4:使用简单
使用宏可以将一部分代码判断直接变成字符串文本常量,用于形成错误提示的一部分。gtest 中 EXPECT_EQ
和 Google Protobuf 中CHECK_EQ
都用了此类技术。使用宏可以在不使用调试函数(如获取调用帧信息、通过调用帧信息获取当前代码位置等)下,将异常的抛出代码位置信息直接在编译器展开时记录下来。
static constexpr int fake_result = 1;
std::string test;
UCLI_ASSURE_GT(fake_result, 9) // ASSURE_XX 之类的宏用于保证某个条件一定能实现,否则就触发异常
<< 503 // int 或 枚举类型会翻译成错误码
<< UnifiedControlCode::UCC_UNCERTAIN_BUSY // UnifiedControlCode 会被视为设置控制码
<< "The server is busy, try later" // 字符串类型会被视为附加的错误信息
<< DoReport<OssCheckPoint<123, 1>>() // DoReport 指令用立即上报一个和框架相关的数值(框架相关)
<< WithReport<tags::InvalidTradeNo>() // WithReport 指令用于对 InvalidTradeNo 标记进行上报(框架无关)
<< WithRes<MyString>("My Additional Text") // WithRes 指令用于抛出异常时附加其他数据
<< [&]() { test = "Oh god!"; }; // 可接受一个 Callable 用于执行附加的操作
operator <<
这样的运算符,也只需要配合示例特化 UnifiedExceptionApplier
即可。UnifiedRpcController controller;
int a = controller.SafeCall([]() {
UCLI_ASSURE_EQ(101, 102) << 500 // 错误码
<< UnifiedControlCode::UCC_UNCERTAIN_RETRY // 表示是可重试的错误
<< "Server down!" // 错误文本
<< WithRes<int>(123456); // 其他附加资源
return 100; // 如果成功就会走到这一步
});
a; // 0 出现了错误,直接返回默认值
controller.IsOk(); // false 控制信息记录错误
controller.IsDone(); // true 控制信息表示代码块完成
controller.IsUncertainRetry(); // true 控制信息表示是结果不确定需要重试
controller.error_code(); // 500 控制信息包含的错误码
controller.ErrorText(); // "Check failed: (101) == (102): Server down!" 断言文本+错误提示
controller.Options<int>() // 123456 其他附加资源
controller
中,这样业务方也就没有心智负担的调用其领域服务的逻辑了,当然也可以直接使用 try..catch..
来处理异常。UnifiedRpcController
已经包含了异常的所需的错误码、控制码、错误信息等,那么也应该有一个方法可以让一些含有异常信息的对象转换为异常抛出。例如:UnifiedRpcController contorller;
contorller.SetResult(UnifiedControlCode::UCC_UNCERTAIN_BUSY, 504, "Server busy");
UCLI_ASSURE_OK(contorller); // 将抛出一个异常,错误信息控制码错误码都来源于 controller
1.5 需求点 5:面向运营和监控
如果这个错误码被运用到某个领域系统的业务逻辑中:因为此错误码关联住了系统和领域,那么当这个错误码发生次数出现异常时(例如和上一个工作日周期做比对),就可以非常快速了解到某个业务逻辑是不是出现了异常。 如果这个错误码被运用到某个基础组件中:错误码被全局管控,可以知道某个机器的特性出现问题,比如某个机器的 KV server 磁盘读写失败次数升高。 如果这个错误码被运用到某个边界系统中:边界系统网网会有比较完善的监控,那么就可以非常快速的知道,在某个业务下的某个边界系统出现问题,这时候为动态运营提供了强有力的切换决策理由。(比如某双路消息订阅系统,在分布式事件中心的压力太大时,事件中心的错误码上报增加,此时可以准备预案切换到某些流量到本地消息队列以缓解事件中心生产者端的压力)。 错误码还可以被简单的集成到模块最终和调用链分析中:通过错误码管理系统可以为模块调用系统提供具体接口级别调用的异常控制聚合分析,对这样的特性异常进行配置告警,并针对这些告警推测可能出现的问题,制定 BCP。
1.6 需求点 6:方便调试
if return
出错了居然还有人忍受,一步步去看日志,一步步去跳转代码查看错误原因?if return
,看到一个错误码就懵逼了根本就不知道是哪个错误条件引起的。所以我们在设计之初就为方便调试做了诸多规划。ProcessInBusiness
函数。调用某组件开发者开发者的一个功能(可能是函数或对象),对应示例中调用 ProcessInComponent
函数;编写自己的业务逻辑; 如果属于自己的业务逻辑,(比如查找某数据不存在,下一步可能是需要插入数据),那么进行逻辑处理,此时无论如何,都表示自己已经对 ProcessInComponent
处理完成了,按照异常处理流程,如果在自己的处理的业务逻辑中,此时应该引发一个新的错误,而不是对上次异常进行重新抛出;如果不属于以自己的业务逻辑,自己的业务流程不能处理,则需要将这个错误码进行转发,并加入自己当前的代码位置以方便调试; 框架一般是将某些虚函数暴露给业务实现,或使用依赖注入的方式将业务处理的函数注入到框架中,此时框架一般都只是转发错误码,并记录转发的代码位置。
调试器可以拿到一个完整的错误链,每个错误链都是由代码中的代码显式上报的; 虽然不是必须的,每次调用链都可以对其中的节点进行错误码转义、甚至是状态码、错误信息都可以添加记录,以保证完整链路中的上下文信息可以完整被捕获到;
PushForward
和SetFail
在语义上由非常大的区别,一个用于在错误信息中添加一个节点的记录,一个表示完全清空错误链信息;某些开发在应该使用 SetFail
时错误使用了PushForward
并转义了错误码,导致只是在错误链中增加了一行源代码记录信息(如上图中右下的 错误码 -2:❶ 基础组件报错 没有被清除);最先被插入的错误信息的依然是组件开发者提供的错误码,因此最终框架把 错误码 -2:❶ 基础组件报错 作为错误的源头,把此组件的错误码作为错误信息返回给主调方,其实业务的想法应该是把 错误码 -1001:业务转义错误码 报告给主调方; 最后框架不得不作为妥协,将 错误码 -1001:❸ 框架转发错误码 中的错误码 -1001 和 错误码 -2:❶ 基础组件报错 中的错误信息 基础组件报错 这样一种畸形的结果报告给主调方,因为错误码的误解造成的危害远远比错误信息造成的误解要来的严重。
1.7 需求点 7:具备扩展性
@ControllerAdvice
或 @RestControllerAdvice
注解,这两个注解都是Spring MVC提供的,作用于控制层的一种切面通知,可以进行全局异常处理、全局数据绑定以及全局数据预处理。GlobalException
),这个异常类可以用于处理项目中的异常,并收集异常信息。这个全局的异常处理类(如GlobalExceptionHandler
)内部使用了 @ExceptionHandler
注解去捕获异常,包括处理自定义异常。总的来说,虽然我们可以为每个业务创建一个唯一的异常子类,但在实践中,这可能会导致代码过于复杂和难以管理。更常见的做法是定义一些通用的异常类,如GlobalException
,并通过全局的异常处理类来捕获和处理这些异常。框架 Xcgi 在解析 Json 数据包中可以提供哪些字段因为哪些规则导致数据解析失败; 组件频率限制组件中可以提供频率出错的规则编号和违反条件; 某分布式业务在使用幂等查重时,发现某个任何正在执行的前置条件未满足而提前终止时前置条件的值。
struct MyString : public string {
using string::string;
std::string ToString() const { return *this; }
};
try {
static constexpr int fake_result = 1;
UCLI_ASSURE_GT(fake_result, 9)
<< 503 << UnifiedControlCode::UCC_UNCERTAIN_BUSY
<< "The server is busy, try later"
<< WithRes<MyString>("My Additional Text");
} catch (const UnifiedException& ex) {
ex.Res<MyString>();
}
WithRes<T>
的模板函数,将某些特定的数据类型在抛出之前放置到异常对象中;当需要关注此异常数据的使用方捕获住异常后,使用 Res<T>
获取抛出时异常对象中的特定数据。02
2.1 异常的建模
错误码:错误码可以作为面向运营和监控的手段,也可以通过集中的管理平台用于集中化的管理和分配,满足 需求点 5 ; 状态码:通过状态码,细化组件、框架、业务代码中的错误的具体的行为,也和 HTTP 状态码保持兼容性,解决 缺点 1; 错误信息:异常抛出方可以使用在异常抛出时自定义错误内容详情,解决 缺点 2; 调试信息:异常抛出方可以记录当前调用帧的指针地址和当前代码行,用于未来通过调试代码的二进制文件获取完整调用帧,解决 需求点 6; 资源池:异常抛出方或捕获者,只有需要用到附加数据时,才需要依赖资源池中的具体头文件,满足 需求点 7。
2.2 异常的兜底
On Error Resume Next
并不是在所有情况下都是最佳的错误处理方式。因为它仅仅是忽略错误,而不是解决错误。如果错误涉及到的是关键任务或者数据,这种做法可能会导致程序在后续运行中出现更严重的问题。因此,应该谨慎使用 On Error Resume Next
,并确保在使用它时能够在适当的地方处理或记录错误。Delphi
中的 madExcept
) 可以提供一定的兜底措施,但如果确实是领域逻辑中会出现的异常,还是应该给出友好的错误提示,并提供可被验证的恢复方案。Error 不能被捕获、可以声明、不可恢复。此类问题常见的场景是内存不足: 如果本身是 IO 进程工作进程多进程模型(绝大多数 svrkit 服务、mqworker),其实可以简单的直接让进程终止(即不处理 std::bad_alloc
这样的异常);如果是多线程模型(所有的mqsvr),因为忽略错误依然无法让已使用的内存得到释放,故这里也没办法处理这样的错误,最好 做法是直接让进程异常终止,再由 CK 脚本重新拉起服务; 如果是通用的二进制工具,这是由于也是无法恢复的,直接 abort
也是一种兜底策略RuntimeException 应该被捕获、可以声明、可恢复的错误。C++11 之后绝大多数类型的基类是 std::runtime_error
对于生产环境,这些可恢复的错误应该被捕获,同时快速的记录上报大致的信息(如类型 ID 错误信息等),也可以为不同的接口分配专用的错误码,用于此类异常点的监控和运营。切记,此时的任务是尽快恢复服务,而非记录现场或开启交互式调试模式; 对于调试环境,职责是尽可能的让程序员找到出现异常问题的代码、上下文、调用帧,以便编写逻辑代码将运行时异常通过添加错误码、上下文信息转换为逻辑异常,例如将 mysqlpp::ConnectionFailed
捕获住,为当前场景添加合适的错误码、带上下文的错误描述等。而由于 C++ 的语言特性,一旦 catch 住异常后,再也没有办法可以获取异常发生时的上下文信息、包括调用帧、代码位置等信息,所以框架此时应该直接让操作系统接管,并生成 coredump 文件用于排查调试模式下的可能出现的运行时异常;std::bad_cast
:使用dynamic_cast
向下转换时失败引发的异常;std::bad_any_cast
:使用std::any_cast<T>
进行拆箱时引发的转换错误;std::bad_optional_access
:使用std::optional<T>::value()
获取没有值时引发的错误;google::protobuf::FatalException
:可能由于使用了不正确的反射获取不匹配消息字段引发;boost::bad_lexical_cast
:使用boost::lexical_cast
进行类型转换引发的异常;fmt::format_error
:使用fmtlib
对目标对象进行格式化时,由于格式化串错误引发的异常;Json::LogicError
:使用JsonCpp
获取不到值时,或无法将 Json 类型进行转换时引发的异常(非常常见);mysqlpp::ConnectionFailed
: 使用 MySQL++ 库连接 MySQL 客户端时无法连接上引发的异常;对于大多数程序而言这些错误的发生并非是自身引起的,有可能是因为环境或调用异构系统时触发的异常,例如: 在我们编写业务代码时,应该随时保持警惕,对于这些异构系统的的异常,应该在第一时间捕获并转换为逻辑异常(对应 Java 中 Checked Exception 概念)。比如使用 MySQL++ 时,对于数据连接不上,应该将 mysqlpp::ConnectionFailed
及时捕获,并在专用系统中登记明确登记错误码,将这个运行时异常转化为逻辑异常(表示这个异常是我已经预期到的,可以被正确的处理的,异常的收敛的也是处理方式之一);框架对于这样的异常,对于框架而言是可恢复的。但由于框架运行的环境和职责不同,所以对待这样的异常应该有所区分: Checked Exception 必须被捕获、必须声明、可恢复的错误。C++11 之后绝大多数的基类是 std::logic_error
,本方案中的对应UnifiedException
。即对于不同框架制作一个适配层用于捕获业务异常,再将其转换为框架的能返回回去。Svrkit 在调用具体的业务函数时捕获 UnifiedException
,将其中的错误码转换为返回码、错误信息注入的回报的error_message
中,其他的信息可以使用 RespCookie 返回;Xcgi/Xwi 支持拦截器,对于 Xcgi 可以将拦截到的异常转为 HTTP 状态码,其他字段转化为回包包体;Xwi 则可以无感的添加组件对所有的事件处理函数进行异常处理转换为 Xwi Context 中的错误状态; 作为框架已经拿到了业务开发的完整上下文了,所以作为框架,完全可以把这个异常集中捕获,根据里面所携带的信息进行集中化处理; 对于支持安装拦截器的框架(如标准 svrkit、Xcgi、Xwi 等),提供拦截器的库,将 UnifiedException
在执行工作函数时将异常捕获,并按照框架的需求返回对于不支持拦截器特性的框架,只能业务方使用 UnifiedRpcController::SafeCall
函数先包一层,再进行到MeshRet
的转换(WxMesh),或在每次调用时使用try...catch...
手动进行异常处理。
2.3 其他人性化的考虑
if
造成干扰。这一点其实对于一个务实的框架码农是非常容易完成的,我们提出的一些更更加人性化的考虑会重点放在调试和运营阶段。调试环境:调试环境可以将调用帧信息直接显示在界面中,解析调用帧信息可能需要比较长的时间(差不多需要 1s)左右,计划是在调试编译条件下启用新的调试命令字,对返回的调用帧指针进行名称的转化; 生产环境:生产环境将异常发生时调用帧信息输出在日志中,并提供统一的入口将帧指针转化为可读的名称,可以在日志系统中留下入口,将某一条错误日志定义到调用帧的每一帧的代码位置(注意:生产环境二进制和调试符号是分开存放的)。